/** * A script that checks the interpolation of a token's movement to detect * whether they have passed through a square containing a trap. * * A trap can be any token on the GM layer for which the cobweb status is * active. Flying tokens (ones with the fluffy-wing status or angel-outfit * status active) will not set off traps unless the traps are also flying. * * This script works best for square traps equal or less than 2x2 squares or * circular traps of any size. */ var ItsATrap = (() => { 'use strict'; const REMOTE_ACTIVATE_CMD = '!itsATrapRemoteActivate'; /** * A degree-2 vector, which is used to represent a point or direction. * @typedef {Number[]} vec2 */ /** * The ItsATrap state data. * @typedef {object} ItsATrapState * @property {object} noticedTraps * The set of IDs for traps that have been noticed by passive perception. * @property {string} theme * The name of the TrapTheme currently being used. */ state.ItsATrap = state.ItsATrap || {}; _.defaults(state.ItsATrap, { noticedTraps: {}, theme: 'default', userOptions: {} }); _.defaults(state.ItsATrap.userOptions, { revealTrapsToMap: false, announcer: 'Admiral Ackbar' }); // Set the theme from the useroptions. let useroptions = globalconfig && globalconfig.itsatrap; if(useroptions) { state.ItsATrap.theme = useroptions['theme'] || 'default'; state.ItsATrap.userOptions = { revealTrapsToMap: useroptions['revealTrapsToMap'] === 'true' || false, announcer: useroptions.announcer || 'Admiral Ackbar' }; } // The collection of registered TrapThemes keyed by name. let trapThemes = {}; /** * Activates a trap. * @param {Graphic} trap * @param {Graphic} [activatingVictim] * The primary victim who set off the trap. */ function activateTrap(trap, activatingVictim) { let theme = getTheme(); // Apply the trap's effects to any victims in its area and to the // activating victim, using the configured trap theme. let victims = getTrapVictims(trap, activatingVictim); _.each(victims, victim => { let effect = new TrapEffect(trap, victim); theme.activateEffect(effect); }); } /** * Activates a trap without any primary victim. * @param {Graphic} trap */ function activateTrapManually(trap) { // Activate the trap with the default theme, which will only display // the trap's message. let defaultTheme = trapThemes['default']; let effect = new TrapEffect(trap); defaultTheme.activateEffect(effect); // Activate the trap upon any characters who happen to be in its area // of effect. activateTrap(trap); } /** * Checks if a token passively searched for any traps during its last * movement. * @private * @param {TrapTheme} theme * @param {Graphic} token */ function _checkPassiveSearch(theme, token) { if(theme.passiveSearch && theme.passiveSearch !== _.noop) { _.chain(getSearchableTraps(token)) .filter(trap => { // Only search for traps that are close enough to be spotted. let effect = new TrapEffect(trap, token); let dist = _getPassiveSearchDistance(token, trap); let searchDist = trap.get('aura2_radius') || effect.searchDist; return (!searchDist || dist < searchDist); }) .each(trap => { theme.passiveSearch(trap, token); }); } } /** * Checks if a token activated or passively spotted any traps during * its last movement. * @private * @param {Graphic} token */ function _checkToken(token) { // Objects on the GM layer don't set off traps. if(token.get("layer") === "objects") { try { let theme = getTheme(); if(!theme) { log('ERROR - It\'s A Trap!: TrapTheme does not exist - ' + state.ItsATrap.theme + '. Using default TrapTheme.'); theme = trapThemes['default']; } // Did the character set off a trap? _checkTrapActivations(theme, token); // If the theme has passive searching, do a passive search for traps. _checkPassiveSearch(theme, token); } catch(err) { log('ERROR - It\'s A Trap!: ' + err.message); log(err.stack); } } } /** * Checks if a token activated any traps during its last movement. * @private * @param {TrapTheme} theme * @param {Graphic} token */ function _checkTrapActivations(theme, token) { let collisions = getTrapCollisions(token); _.find(collisions, collision => { var trap = collision.other; // Skip if the trap is disabled. if(trap.get('status_interdiction')) return false; var trapEffect = (new TrapEffect(trap, token)).json; trapEffect.stopAt = trapEffect.stopAt || 'center'; // Figure out where to stop the token. if(trapEffect.stopAt === 'edge' && !trapEffect.gmOnly) { var x = collision.pt[0]; var y = collision.pt[1]; token.set("lastmove",""); token.set("left", x); token.set("top", y); } else if(trapEffect.stopAt === 'center' && !trapEffect.gmOnly) moveTokenToTrap(token, trap); // Apply the trap's effects to any victims in its area. activateTrap(trap, token); // Stop activating traps if this trap stopped the token. return (trapEffect.stopAt !== 'none'); }); } /** * Gets the distance between two tokens in their page's units. * @param {Graphic} token1 * @param {Graphic} token2 * @return {number} */ function _getPassiveSearchDistance(token1, token2) { let p1 = [ token1.get('left'), token1.get('top') ]; let p2 = [ token2.get('left'), token2.get('top') ]; let r1 = token1.get('width')/2; let r2 = token2.get('width')/2; let page = getObj('page', token1.get('_pageid')); let scale = page.get('scale_number'); let pixelDist = Math.max(0, VecMath.dist(p1, p2) - r1 - r2); return pixelDist/70*scale; } /** * Gets the tokens that a token has line of sight to. * @private * @param {Graphic} token * @param {Graphic[]} otherTokens * @param {number} [range=Infinity] * The line-of-sight range in pixels. * @param {boolean} [isSquareRange=false] * @return {Graphic[]} */ function _getTokensInLineOfSight(token, otherTokens, range, isSquareRange) { if(_.isUndefined(range)) range = Infinity; var pageId = token.get('_pageid'); var tokenPt = [ token.get('left'), token.get('top'), 1 ]; var tokenRW = token.get('width')/2-1; var tokenRH = token.get('height')/2-1; var wallPaths = findObjs({ _type: 'path', _pageid: pageId, layer: 'walls' }); var wallSegments = PathMath.toSegments(wallPaths); return _.filter(otherTokens, other => { var otherPt = [ other.get('left'), other.get('top'), 1 ]; var otherRW = other.get('width')/2; var otherRH = other.get('height')/2; // Skip tokens that are out of range. if(isSquareRange && ( Math.abs(tokenPt[0]-otherPt[0]) >= range + otherRW + tokenRW || Math.abs(tokenPt[1]-otherPt[1]) >= range + otherRH + tokenRH)) return false else if(!isSquareRange && VecMath.dist(tokenPt, otherPt) >= range + tokenRW + otherRW) return false; var segToOther = [tokenPt, otherPt]; return !_.find(wallSegments, wallSeg => { return PathMath.segmentIntersection(segToOther, wallSeg); }); }); } /** * Gets all the traps that a token has line-of-sight to, with no limit for * range. Line-of-sight is blocked by paths on the dynamic lighting layer. * @param {Graphic} charToken * @return {Graphic[]} * The list of traps that charToken has line-of-sight to. */ function getSearchableTraps(charToken) { var pageId = charToken.get('_pageid'); var traps = getTrapsOnPage(pageId); return _getTokensInLineOfSight(charToken, traps); } /** * Gets the theme currently being used to interpret TrapEffects spawned * when a character activates a trap. * @return {TrapTheme} */ function getTheme() { return trapThemes[state.ItsATrap.theme]; } /** * Returns the first trap a token collided with during its last movement. * If it didn't collide with any traps, return false. * @deprecated * @param {Graphic} token * @return {Graphic || false} */ function getTrapCollision(token) { var pageId = token.get('_pageid'); var traps = getTrapsOnPage(pageId); // Some traps don't affect flying tokens. traps = _.filter(traps, trap => { return !isTokenFlying(token) || isTokenFlying(trap); }); return TokenCollisions.getFirstCollision(token, traps); } /** * Returns the list of all traps a token would collide with during its last * movement. The traps are sorted in the order that the token will collide * with them. * @param {Graphic} token * @return {TokenCollisions.Collision[]} */ function getTrapCollisions(token) { let pageId = token.get('_pageid'); let traps = getTrapsOnPage(pageId); // Some traps don't affect flying tokens. traps = _.filter(traps, trap => { return !isTokenFlying(token) || isTokenFlying(trap); }); return TokenCollisions.getCollisions(token, traps, {detailed: true}); } /** * Gets the list of all the traps on the specified page. * @param {string} pageId * @return {Graphic[]} */ function getTrapsOnPage(pageId) { return findObjs({ _pageid: pageId, _type: "graphic", status_cobweb: true, layer: "gmlayer" }); } /** * Gets the list of victims within an activated trap's area of effect. * @param {Graphic} trap * @param {Graphic} triggerVictim * @return {Graphic[]} */ function getTrapVictims(trap, triggerVictim) { let range = trap.get('aura1_radius'); let pageId = trap.get('_pageid'); let victims = [triggerVictim]; if(range !== '') { let otherTokens = findObjs({ _pageid: pageId, _type: 'graphic', layer: 'objects' }); let pageScale = getObj('page', pageId).get('scale_number'); range *= 70/pageScale; let squareArea = trap.get('aura1_square'); victims = victims.concat(_getTokensInLineOfSight(trap, otherTokens, range, squareArea)); } return _.chain(victims) .unique() .compact() .value(); } /** * @private */ function _getUserOption(opName) { return state.ItsATrap.userOptions[opName]; } /** * Determines whether a token is currently flying. * @param {Graphic} token * @return {Boolean} */ function isTokenFlying(token) { return token.get("status_fluffy-wing"); } /** * Marks a trap with a circle and a ping. * @private * @param {Graphic} trap */ function _markTrap(trap) { let radius = trap.get('width')/2; let x = trap.get('left'); let y = trap.get('top'); let pageId = trap.get('_pageid'); // Circle the trap's trigger area. let circle = PathMath.createCircleData(radius); createObj('path', _.extend(circle, { layer: 'objects', left: x, _pageid: pageId, stroke: '#ffff00', // yellow stroke_width: 5, top: y })); sendPing(x, y, pageId); } /** * Moves the specified token to the same position as the trap. * @param {Graphic} token * @param {Graphic} trap */ function moveTokenToTrap(token, trap) { let x = trap.get("left"); let y = trap.get("top"); token.set("lastmove",""); token.set("left", x); token.set("top", y); } /** * Marks a trap as being noticed by a character's passive search. * Does nothing if the trap has already been noticed. * @param {Graphic} trap * @param {string} A message to display when the trap is noticed. * @return {boolean} * true if the trap has not been noticed yet. */ function noticeTrap(trap, noticeMessage) { let id = trap.get('_id'); let effect = new TrapEffect(trap); if(!state.ItsATrap.noticedTraps[id]) { state.ItsATrap.noticedTraps[id] = true; sendChat('Admiral Ackbar', noticeMessage); if(effect.revealWhenSpotted) revealTrap(trap); else _markTrap(trap); return true; } else return false; } /** * Registers a TrapTheme. * @param {TrapTheme} theme */ function registerTheme(theme) { log('It\'s A Trap!: Registered TrapTheme - ' + theme.name + '.'); trapThemes[theme.name] = theme; } /** * Reveals a trap with the bleeding-eye status to either the back of the * objects layer, or the front of the map layer, depending on the user * configurations. * @param {Graphic} trap * @param {boolean} [force] * If true, the trap is revealed even if it doesn't have the * bleeding-eye status. */ function revealTrap(trap, force) { let effect = new TrapEffect(trap); if(effect.revealLayer === 'objects') { trap.set('layer', 'objects'); toBack(trap); } else { trap.set('layer', 'map'); toFront(trap); } sendPing(trap.get('left'), trap.get('top'), trap.get('_pageid')); } // Create macro for the remote activation command. on('ready', () => { let numRetries = 3; let interval = setInterval(() => { let theme = getTheme(); if(theme) { log(`☒☠☒ Initialized It's A Trap! using theme '${getTheme().name}' ☒☠☒`); clearInterval(interval); } else if(numRetries > 0) numRetries--; else clearInterval(interval); }, 1000); }); // Handle macro commands. on('chat:message', msg => { if(msg.content === REMOTE_ACTIVATE_CMD) { let theme = getTheme(); _.each(msg.selected, item => { let trap = getObj('graphic', item._id); activateTrapManually(trap); }); } }); /** * When a graphic on the objects layer moves, run the script to see if it * passed through any traps. */ on("change:graphic:lastmove", function(token) { try { _checkToken(token); } catch(err) { log(`It's A Trap ERROR: ${err.msg}`); log(err.stack); } }); // When a trap's token is destroyed, remove it from the set of noticed traps. on('destroy:graphic', function(token) { let id = token.get('_id'); if(state.ItsATrap.noticedTraps[id]) delete state.ItsATrap.noticedTraps[id]; }); return { activateTrap, activateTrapManually, getTheme, getTrapCollision, getTrapCollisions, getTrapsOnPage, isTokenFlying, moveTokenToTrap, noticeTrap, registerTheme, revealTrap, REMOTE_ACTIVATE_CMD }; })(); /** * The configured JSON properties of a trap. This can be extended to add * additional properties for system-specific themes. */ var TrapEffect = (() => { 'use strict'; const DEFAULT_FX = { maxParticles: 100, emissionRate: 3, size: 35, sizeRandom: 15, lifeSpan: 10, lifeSpanRandom: 3, speed: 3, speedRandom: 1.5, gravity: {x: 0.01, y: 0.01}, angle: 0, angleRandom: 180, duration: -1, startColour: [220, 35, 0, 1], startColourRandom: [62, 0, 0, 0.25], endColour: [220, 35, 0, 0], endColourRandom:[60, 60, 60, 0] }; return class TrapEffect { /** * An API chat command that will be executed when the trap is activated. * If the constants TRAP_ID and VICTIM_ID are provided, * they will be replaced by the IDs for the trap token and the token for * the trap's victim, respectively in the API chat command message. * @type {string} */ get api() { return this._effect.api; } /** * Configuration for special FX that are created when the trap activates. * @type {object} * @property {(string | FxJsonDefinition)} name * Either the name of the FX that is created * (built-in or user-made), or a custom FX JSON defintion. * @property {vec2} offset * The offset of the special FX, in units from the trap's token. * @property {vec2} direction * For beam-like FX, this specifies the vector for the FX's * direction. If left blank, it will fire towards the token * that activated the trap. */ get fx() { return this._effect.fx; } /** * Whether the trap should only be announced to the GM when it is activated. * @type {boolean} */ get gmOnly() { return this._effect.gmOnly; } /** * Gets a copy of the trap's JSON properties. * @readonly * @type {object} */ get json() { return _.clone(this._effect); } /** * The flavor message displayed when the trap is activated. If left * blank, a default message will be generated based on the name of the * trap's token. * @type {string} */ get message() { return this._effect.message || this._createDefaultTrapMessage(); } /** * The trap's name. * @type {string} */ get name() { return this._trap.get('name'); } /** * Secret notes for the GM. * @type {string} */ get notes() { return this._effect.notes; } /** * The layer that the trap gets revealed to. * @type {string} */ get revealLayer() { return this._effect.revealLayer; } /** * Whether the trap is revealed when it is spotted. * @type {boolean} */ get revealWhenSpotted() { return this._effect.revealWhenSpotted; } /** * The name of a sound played when the trap is activated. * @type {string} */ get sound() { return this._effect.sound; } /** * This is where the trap stops the token. * If "edge", then the token is stopped at the trap's edge. * If "center", then the token is stopped at the trap's center. * If "none", the token is not stopped by the trap. * @type {string} */ get stopAt() { return this._effect.stopAt; } /** * The trap this TrapEffect represents. * @type {Graphic} */ get trap() { return this._trap; } /** * The ID of the trap. * @type {uuid} */ get trapId() { return this._trap.get('_id'); } /** * A list of names or IDs for traps that will also be triggered when this * trap is activated. * @type {string[]} */ get triggers() { return this._effect.triggers; } /** * The name for the trap/secret's type displayed in automated messages. * @type {string} */ get type() { return this._effect.type; } /** * The victim who activated the trap. * @type {Graphic} */ get victim() { return this._victim; } /** * The ID of the trap's victim. * @type {uuid} */ get victimId() { return this._victim && this._victim.get('_id'); } /** * @param {Graphic} trap * The trap's token. * @param {Graphic} [victim] * The token for the character that activated the trap. */ constructor(trap, victim) { let effect = {}; // URI-escape the notes and remove the HTML elements. let notes = trap.get('gmnotes'); try { notes = decodeURIComponent(notes).trim(); } catch(err) { notes = unescape(notes).trim(); } if(notes) { try { notes = notes.split(/<[/]?.+?>/g).join(''); effect = JSON.parse(notes); } catch(err) { effect.message = 'ERROR: invalid TrapEffect JSON.'; } } this._effect = effect; this._trap = trap; this._victim = victim; } /** * Activates the traps that are triggered by this trap. */ activateTriggers() { let triggers = this.triggers; if(triggers) { let otherTraps = ItsATrap.getTrapsOnPage(this._trap.get('_pageid')); let triggeredTraps = _.filter(otherTraps, trap => { // Skip if the trap is disabled. if(trap.get('status_interdiction')) return false; return triggers.indexOf(trap.get('name')) !== -1 || triggers.indexOf(trap.get('_id')) !== -1; }); _.each(triggeredTraps, trap => { ItsATrap.activateTrapManually(trap); }); } } /** * Announces the activated trap. * This should be called by TrapThemes to inform everyone about a trap * that has been triggered and its results. Fancy HTML formatting for * the message is encouraged. If the trap's effect has gmOnly set, * then the message will only be shown to the GM. * This also takes care of playing the trap's sound, FX, and API command, * they are provided. * @param {string} [message] * The message for the trap results displayed in the chat. If * omitted, then the trap's raw message will be displayed. */ announce(message) { message = message || this.message; let announcer = state.ItsATrap.userOptions.announcer; // Display the message to everyone, unless it's a secret. if(this.gmOnly) { message = '/w gm ' + message; sendChat(announcer, message); // Whisper any secret notes to the GM. if(this.notes) sendChat(announcer, '/w gm Trap Notes:
' + this.notes); } else { sendChat(announcer, message); // Whisper any secret notes to the GM. if(this.notes) sendChat(announcer, '/w gm Trap Notes:
' + this.notes); // Reveal the trap if it's set to become visible. if(this.trap.get('status_bleeding-eye')) ItsATrap.revealTrap(this.trap); // If the effect has a sound, try to play it. this.playSound(); // If the effect has fx, play them. this.playFX(); // If the effect has an api command, execute it. this.playApi(); // Allow traps to trigger each other using the 'triggers' property. this.activateTriggers(); } } /** * Creates a default message for the trap. * @private * @return {string} */ _createDefaultTrapMessage() { if(this.victim) { if(this.name) return `${this.victim.get('name')} set off a trap: ${this.name}!`; else return `${this.victim.get('name')} set off a trap!`; } else { if(this.name) return `${this.name} was activated!`; else return `A trap was activated!`; } } /** * Executes the trap's API chat command if it has one. */ playApi() { let api = this.api; if(api) { api = api.split('TRAP_ID').join(this.trapId); api = api.split('VICTIM_ID').join(this.victimId); sendChat('ItsATrap-api', api); } } /** * Spawns built-in or custom FX for an activated trap. */ playFX() { var pageId = this._trap.get('_pageid'); if(this.fx) { var offset = this.fx.offset || [0, 0]; var origin = [ this._trap.get('left') + offset[0]*70, this._trap.get('top') + offset[1]*70 ]; var direction = this.fx.direction || (() => { if(this._victim) return [ this._victim.get('left') - origin[0], this._victim.get('top') - origin[1] ]; else return [ 0, 1 ]; })(); this._playFXNamed(this.fx.name, pageId, origin, direction); } } /** * Play FX using a named effect. * @private * @param {string} name * @param {uuid} pageId * @param {vec2} origin * @param {vec2} direction */ _playFXNamed(name, pageId, origin, direction) { let x = origin[0]; let y = origin[1]; let fx = name; let isBeamLike = false; var custFx = findObjs({ _type: 'custfx', name: name })[0]; if(custFx) { fx = custFx.get('_id'); isBeamLike = custFx.get('definition').angle === -1; } else isBeamLike = !!_.find(['beam-', 'breath-', 'splatter-'], type => { return name.startsWith(type); }); if(isBeamLike) { let p1 = { x: x, y: y }; let p2 = { x: x + direction[0], y: y + direction[1] }; spawnFxBetweenPoints(p1, p2, fx, pageId); } else spawnFx(x, y, fx, pageId); } /** * Plays a TrapEffect's sound, if it has one. */ playSound() { if(this.sound) { var sound = findObjs({ _type: 'jukeboxtrack', title: this.sound })[0]; if(sound) { sound.set('playing', true); sound.set('softstop', false); } else throw new Error('Could not find sound "' + this.sound + '".'); } } } })(); /** * A module that presents a wizard for setting up traps instead of * hand-crafting the JSON for them. */ var ItsATrapCreationWizard = (() => { 'use strict'; const DISPLAY_WIZARD_CMD = '!ItsATrap_trapCreationWizard_showMenu'; const MODIFY_CORE_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapCore'; const MODIFY_THEME_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapTheme'; const MENU_CSS = { 'optionsTable': { 'width': '100%' }, 'menu': { 'background': '#fff', 'border': 'solid 1px #000', 'border-radius': '5px', 'font-weight': 'bold', 'margin-bottom': '1em', 'overflow': 'hidden' }, 'menuBody': { 'padding': '5px', 'text-align': 'center' }, 'menuHeader': { 'background': '#000', 'color': '#fff', 'text-align': 'center' } }; /** * Displays the menu for setting up a trap. * @param {string} who * @param {string} playerid * @param {Graphic} trapToken */ function displayWizard(who, playerId, trapToken) { let content = new HtmlBuilder('div'); // Core properties content.append('h4', 'Core properties'); let properties = getCoreProperties(trapToken); content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, properties)); // Theme properties let theme = ItsATrap.getTheme(); if(theme.getThemeProperties) { content.append('h4', 'Theme-specific properties', { style: { 'margin-top' : '2em' } }); let properties = theme.getThemeProperties(trapToken); content.append(_displayWizardProperties(MODIFY_THEME_PROPERTY_CMD, properties)); } // Remote activate button content.append('div', `[Activate Trap](${ItsATrap.REMOTE_ACTIVATE_CMD})`, { style: { 'margin-top' : '2em' } }); let menu = _showMenuPanel('Trap Configuration', content); _whisper(who, menu.toString(MENU_CSS)); trapToken.set('status_cobweb', true); } /** * Creates the table for a list of trap properties. * @private */ function _displayWizardProperties(modificationCommand, properties) { let table = new HtmlBuilder('table'); _.each(properties, prop => { let row = table.append('tr', undefined, { title: prop.desc }); // Construct the list of parameter prompts. let params = []; let paramProperties = prop.properties || [prop]; _.each(paramProperties, item => { let options = ''; if(item.options) options = '|' + item.options.join('|'); params.push(`?{${item.name} ${item.desc} ${options}}`); }); row.append('td', `[${prop.name}](${modificationCommand} ${prop.id}&&${params.join('&&')})`, { style: { 'font-size': '0.8em' } }); row.append('td', `${prop.value || ''}`, { style: { 'font-size': '0.8em' } }); }); return table; } /** * Fixes msg.who. * @param {string} who * @return {string} */ function _fixWho(who) { return who.replace(/\(GM\)/, '').trim(); } /** * Gets a list of the core trap properties for a trap token. * @param {Graphic} token * @return {object[]} */ function getCoreProperties(trapToken) { let trapEffect = (new TrapEffect(trapToken)).json; let LPAREN = '('; let RPAREN = ')'; let LBRACE = '['; let RBRACE = ']'; return [ { id: 'name', name: 'Name', desc: 'The name of the trap', value: trapToken.get('name') }, { id: 'type', name: 'Type', desc: 'Is this a trap, or some other hidden secret?', value: trapEffect.type || 'trap' }, { id: 'api', name: 'API Command', desc: 'An API command which the trap runs when it is activated. The constants TRAP_ID and VICTIM_ID will be replaced by the object IDs for the trap and victim.', value: trapEffect.api }, { id: 'disabled', name: 'Disabled', desc: 'Disable this trap from activating?', value: trapToken.get('status_interdiction') ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'effectDistance', name: 'Effect distance', desc: 'How far away can the trap affect other tokens?', value: trapToken.get('aura1_radius') || 0 }, { id: 'effectShape', name: 'Effect shape', desc: 'Is the shape of the trap\'s effect circular or square?', value: trapToken.get('aura1_square') ? 'square' : 'circle', options: [ 'circle', 'square' ] }, { id: 'flying', name: 'Flying', desc: 'Should this trap affect flying tokens ' + LPAREN + 'fluffy-wing status ' + RPAREN + '?', value: trapToken.get('status_fluffy-wing') ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'fx', name: 'Special FX', desc: 'What special FX are displayed when the trap is activated?', value: (() => { let fx = trapEffect.fx; if(fx) { let result = fx.name; if(fx.offset) result += '; Offset: ' + fx.offset; if(fx.direction) result += '; Direction: ' + fx.direction; return result; } else return 'None' })(), properties: [ { id: 'name', name: 'FX Name', desc: 'The name of the special FX.' }, { id: 'offset', name: 'FX Offset', desc: 'The offset ' + LPAREN + 'in units' + RPAREN + ' of the special FX from the trap\'s center. Format: ' + LBRACE + 'X,Y' + RBRACE }, { id: 'direction', name: 'FX Direction', desc: 'The directional vector for the special FX ' + LPAREN + 'Leave blank to direct it towards characters' + RPAREN + '. Format: ' + LBRACE + 'X,Y' + RBRACE } ] }, { id: 'gmOnly', name: 'GM Only', desc: 'When the trap is activated, should its results only be displayed to the GM?', value: trapEffect.gmOnly ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'message', name: 'Message', desc: 'The message displayed when the trap is activated.', value: trapEffect.message }, { id: 'notes', name: 'Secret Notes', desc: 'Additional secret notes shown only to the GM when the trap is activated.', value: trapEffect.notes }, { id: 'revealToPlayers', name: 'Reveal When Activated', desc: 'Should this trap be revealed to the players when it is activated?', value: trapToken.get('status_bleeding-eye') ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'revealWhenSpotted', name: 'Reveal When Spotted', desc: 'Should this trap be revealed to the players when a character notices it by passive searching?', value: trapEffect.revealWhenSpotted ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'revealLayer', name: 'Reveal Layer', desc: 'When this trap is revealed, which layer is it revealed on?', value: trapEffect.revealLayer || 'map', options: ['map', 'objects'] }, { id: 'searchDist', name: 'Passive Search Distance', desc: 'How far away can characters passively search for this trap?', value: trapToken.get('aura2_radius') || trapEffect.searchDist }, { id: 'sound', name: 'Sound', desc: 'A sound from your jukebox that will play when the trap is activated.', value: trapEffect.sound }, { id: 'stopAt', name: 'Stops Tokens At', desc: 'Does this trap stop tokens that pass through its trigger area?', value: trapEffect.stopAt, options: ['center', 'edge', 'none'] }, { id: 'triggers', name: 'Other Traps Triggered', desc: 'A list of the names or token IDs for other traps that are triggered when this trap is activated.', value: (() => { let triggers = trapEffect.triggers; if(_.isString(triggers)) triggers = [triggers]; if(triggers) return triggers.join(', '); else return undefined; })() } ]; } /** * Changes a property for a trap. * @param {Graphic} trapToken * @param {Array} params */ function modifyTrapProperty(trapToken, argv) { let trapEffect = (new TrapEffect(trapToken)).json; let prop = argv[0]; let params = argv.slice(1); if(prop === 'name') trapToken.set('name', params[0]); if(prop === 'type') trapEffect.type = params[0]; if(prop === 'api') trapEffect.api = params[0]; if(prop === 'disabled') trapToken.set('status_interdiction', params[0] === 'yes'); if(prop === 'effectDistance') trapToken.set('aura1_radius', parseInt(params[0])); if(prop === 'effectShape') trapToken.set('aura1_square', params[0] === 'square'); if(prop === 'flying') trapToken.set('status_fluffy-wing', params[0] === 'yes'); if(prop === 'fx') { if(params[0]) { trapEffect.fx = {}; trapEffect.fx.name = params[0]; try { trapEffect.fx.offset = JSON.parse(params[1]); } catch(err) {} try { trapEffect.fx.direction = JSON.parse(params[2]); } catch(err) {} } else trapEffect.fx = undefined; } if(prop === 'gmOnly') trapEffect.gmOnly = params[0] === 'yes'; if(prop === 'message') trapEffect.message = params[0]; if(prop === 'notes') trapEffect.notes = params[0]; if(prop === 'revealLayer') trapEffect.revealLayer = params[0]; if(prop === 'revealToPlayers') trapToken.set('status_bleeding-eye', params[0] === 'yes'); if(prop === 'revealWhenSpotted') trapEffect.revealWhenSpotted = params[0] === 'yes'; if(prop === 'searchDist') trapToken.set('aura2_radius', parseInt(params[0])); if(prop === 'sound') trapEffect.sound = params[0]; if(prop === 'stopAt') trapEffect.stopAt = params[0]; if(prop === 'triggers') trapEffect.triggers = _.map(params[0].split(','), trigger => { return trigger.trim(); }); trapToken.set('gmnotes', JSON.stringify(trapEffect)); } /** * Displays one of the script's menus. * @param {string} header * @param {(string|HtmlBuilder)} content * @return {HtmlBuilder} */ function _showMenuPanel(header, content) { let menu = new HtmlBuilder('.menu'); menu.append('.menuHeader', header); menu.append('.menuBody', content) return menu; } /** * @private * Whispers a Marching Order message to someone. */ function _whisper(who, msg) { sendChat('Its A Trap! script', '/w "' + _fixWho(who) + '" ' + msg); } on('ready', () => { let macro = findObjs({ _type: 'macro', name: 'ItsATrap_trapCreationWizard' })[0]; if(!macro) { let players = findObjs({ _type: 'player' }); let gms = _.filter(players, player => { return playerIsGM(player.get('_id')); }); _.each(gms, gm => { createObj('macro', { _playerid: gm.get('_id'), name: 'ItsATrap_trapCreationWizard', action: DISPLAY_WIZARD_CMD, istokenaction: true }); }); } }); on('chat:message', msg => { try { if(msg.content.startsWith(DISPLAY_WIZARD_CMD)) { let trapToken = getObj('graphic', msg.selected[0]._id); displayWizard(msg.who, msg.playerId, trapToken); } if(msg.content.startsWith(MODIFY_CORE_PROPERTY_CMD)) { let trapToken = getObj('graphic', msg.selected[0]._id); let params = msg.content.replace(MODIFY_CORE_PROPERTY_CMD + ' ', '').split('&&'); modifyTrapProperty(trapToken, params); displayWizard(msg.who, msg.playerId, trapToken); } if(msg.content.startsWith(MODIFY_THEME_PROPERTY_CMD)) { let trapToken = getObj('graphic', msg.selected[0]._id); let params = msg.content.replace(MODIFY_THEME_PROPERTY_CMD + ' ', '').split('&&'); let theme = ItsATrap.getTheme(); theme.modifyTrapProperty(trapToken, params); displayWizard(msg.who, msg.playerId, trapToken); } } catch(err) { log('ItsATrapCreationWizard: ' + err.message); log(err.stack); } }); return { displayWizard, DISPLAY_WIZARD_CMD, MODIFY_CORE_PROPERTY_CMD, MODIFY_THEME_PROPERTY_CMD }; })(); /** * Base class for trap themes: System-specific strategies for handling * automation of trap activation results and passive searching. * @abstract */ var TrapTheme = (() => { 'use strict'; /** * The name of the theme used to register it. * @type {string} */ return class TrapTheme { /** * A sample CSS object for trap HTML messages created with HTML Builder. */ static get css() { return { 'bold': { 'font-weight': 'bold' }, 'critFail': { 'border': '2px solid #B31515' }, 'critSuccess': { 'border': '2px solid #3FB315' }, 'hit': { 'color': '#f00', 'font-weight': 'bold' }, 'miss': { 'color': '#620', 'font-weight': 'bold' }, 'paddedRow': { 'padding': '1px 1em' }, 'rollResult': { 'background-color': '#FEF68E', 'cursor': 'help', 'font-size': '1.1em', 'font-weight': 'bold', 'padding': '0 3px' }, 'trapMessage': { 'background-color': '#ccc', 'font-style': 'italic' }, 'trapTable': { 'background-color': '#fff', 'border': 'solid 1px #000', 'border-collapse': 'separate', 'border-radius': '10px', 'overflow': 'hidden', 'width': '100%' }, 'trapTableHead': { 'background-color': '#000', 'color': '#fff', 'font-weight': 'bold' } }; } get name() { throw new Error('Not implemented.'); } /** * Activates a TrapEffect by displaying the trap's message and * automating any system specific trap mechanics for it. * @abstract * @param {TrapEffect} effect */ activateEffect(effect) { throw new Error('Not implemented.'); } /** * Asynchronously gets the value of a character sheet attribute. * @param {Character} character * @param {string} attr * @return {Promise} * Contains the value of the attribute. */ static getSheetAttr(character, attr) { let rollExpr = '@{' + character.get('name') + '|' + attr + '}'; return CharSheetUtils.rollAsync(rollExpr) .then((roll) => { if(roll) return roll.total; else throw new Error('Could not resolve roll expression: ' + rollExpr); }); } /** * Gets a list of a trap's theme-specific configured properties. * @param {Graphic} trap * @return {TrapProperty[]} */ getThemeProperties(trap) { return []; } /** * Displays the message to notice a trap. * @param {Character} character * @param {Graphic} trap */ static htmlNoticeTrap(character, trap) { let content = new HtmlBuilder(); let effect = new TrapEffect(trap, character); content.append('.paddedRow trapMessage', character.get('name') + ' notices a ' + (effect.type || 'trap') + ':'); content.append('.paddedRow', trap.get('name')); return TrapTheme.htmlTable(content, '#000', effect); } /** * Sends an HTML-stylized message about a noticed trap. * @param {(HtmlBuilder|string)} content * @param {string} borderColor * @param {TrapEffect} [effect] * @return {HtmlBuilder} */ static htmlTable(content, borderColor, effect) { let type = (effect && effect.type) || 'trap'; let table = new HtmlBuilder('table.trapTable', '', { style: { 'border-color': borderColor } }); table.append('thead.trapTableHead', '', { style: { 'background-color': borderColor } }).append('th', 'IT\'S A ' + type.toUpperCase() + '!!!'); table.append('tbody').append('tr').append('td', content, { style: { 'padding': '0' } }); return table; } /** * Changes a theme-specific property for a trap. * @param {Graphic} trapToken * @param {Array} params */ modifyTrapProperty(trapToken, argv) { // Default implementation: Do nothing. } /** * The system-specific behavior for a character passively noticing a trap. * @abstract * @param {Graphic} trap * The trap's token. * @param {Graphic} charToken * The character's token. */ passiveSearch(trap, charToken) { throw new Error('Not implemented.'); } /** * Asynchronously rolls a dice roll expression and returns the result's total in * a callback. The result is undefined if an invalid expression is given. * @param {string} expr * @return {Promise} */ static rollAsync(expr) { return new Promise((resolve, reject) => { sendChat('TrapTheme', '/w gm [[' + expr + ']]', (msg) => { try { let results = msg[0].inlinerolls[0].results; resolve(results); } catch(err) { reject(err); } }); }); } }; })(); /** * A base class for trap themes using the D20 system (D&D 3.5, 4E, 5E, Pathfinder, etc.) * @abstract */ var D20TrapTheme = (() => { 'use strict'; return class D20TrapTheme extends TrapTheme { /** * @inheritdoc */ activateEffect(effect) { let character = getObj('character', effect.victim.get('represents')); let effectResults = effect.json; // Automate trap attack/save mechanics. Promise.resolve() .then(() => { effectResults.character = character; if(character) { if(effectResults.attack) return this._doTrapAttack(character, effectResults); else if(effectResults.save && effectResults.saveDC) return this._doTrapSave(character, effectResults); } return effectResults; }) .then(effectResults => { let html = D20TrapTheme.htmlTrapActivation(effectResults); effect.announce(html.toString(TrapTheme.css)); }) .catch(err => { sendChat('TrapTheme: ' + this.name, '/w gm ' + err.message); log(err.stack); }); } /** * Does a trap's attack roll. * @private */ _doTrapAttack(character, effectResults) { return Promise.all([ this.getAC(character), CharSheetUtils.rollAsync('1d20 + ' + effectResults.attack) ]) .then(tuple => { let ac = tuple[0]; let atkRoll = tuple[1]; ac = ac || 10; effectResults.ac = ac; effectResults.roll = atkRoll; effectResults.trapHit = atkRoll.total >= ac; return effectResults; }); } /** * Does a trap's save. * @private */ _doTrapSave(character, effectResults) { return this.getSaveBonus(character, effectResults.save) .then(saveBonus => { saveBonus = saveBonus || 0; effectResults.saveBonus = saveBonus; return CharSheetUtils.rollAsync('1d20 + ' + saveBonus); }) .then((saveRoll) => { effectResults.roll = saveRoll; effectResults.trapHit = saveRoll.total < effectResults.saveDC; return effectResults; }); } /** * Gets a character's AC. * @abstract * @param {Character} character * @return {Promise} */ getAC(character) { throw new Error('Not implemented.'); } /** * Gets a character's passive wisdom (Perception). * @abstract * @param {Character} character * @return {Promise} */ getPassivePerception(character) { throw new Error('Not implemented.'); } /** * Gets a character's saving throw bonus. * @abstract * @param {Character} character * @return {Promise} */ getSaveBonus(character, saveName) { throw new Error('Not implemented.'); } /** * @inheritdoc */ getThemeProperties(trapToken) { let trapEffect = (new TrapEffect(trapToken)).json; let LPAREN = '('; let RPAREN = ')'; let LBRACE = '['; let RBRACE = ']'; return [ { id: 'attack', name: 'Attack Bonus', desc: `The trap's attack roll bonus vs AC.`, value: trapEffect.attack }, { id: 'damage', name: 'Damage', desc: `The dice roll expression for the trap's damage.`, value: trapEffect.damage }, { id: 'hideSave', name: 'Hide Save Result', desc: 'Show the Saving Throw result only to the GM?', value: trapEffect.hideSave ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'missHalf', name: 'Miss - Half Damage', desc: 'Does the trap deal half damage on a miss?', value: trapEffect.missHalf ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'save', name: 'Saving Throw', desc: 'The type of saving throw used by the trap.', value: trapEffect.save, options: [ 'none', 'str', 'dex', 'con', 'int', 'wis', 'cha' ] }, { id: 'saveDC', name: 'Saving Throw DC', desc: `The DC for the trap's saving throw.`, value: trapEffect.saveDC }, { id: 'spotDC', name: 'Spot DC', desc: 'The skill check DC to spot the trap.', value: trapEffect.spotDC } ]; } /** * Produces HTML for a faked inline roll result for d20 systems. * @param {int} result * @param {string} tooltip * @return {HtmlBuilder} */ static htmlRollResult(result, tooltip) { let d20 = result.rolls[0].results[0].v; let clazzes = ['rollResult']; if(d20 === 20) clazzes.push('critSuccess'); if(d20 === 1) clazzes.push('critFail'); return new HtmlBuilder('span.' + clazzes.join(' '), result.total, { title: tooltip }); } /** * Produces the HTML for a trap activation message for most d20 systems. * @param {object} effectResults * @return {HtmlBuilder} */ static htmlTrapActivation(effectResults) { let content = new HtmlBuilder('div'); // Add the flavor message. content.append('.paddedRow trapMessage', effectResults.message); if(effectResults.character) { var row = content.append('.paddedRow'); row.append('span.bold', 'Target:'); row.append('span', effectResults.character.get('name')); // Add the attack roll message. if(effectResults.attack) { let rollResult = D20TrapTheme.htmlRollResult(effectResults.roll, '1d20 + ' + effectResults.attack); content.append('.paddedRow') .append('span.bold', 'Attack roll:') .append('span', rollResult) .append('span', ' vs AC ' + effectResults.ac); } // Add the saving throw message. if(effectResults.save) { let rollResult = D20TrapTheme.htmlRollResult(effectResults.roll, '1d20 + ' + effectResults.saveBonus); let saveMsg = new HtmlBuilder('.paddedRow'); saveMsg.append('span.bold', effectResults.save.toUpperCase() + ' save:'); saveMsg.append('span', rollResult); saveMsg.append('span', ' vs DC ' + effectResults.saveDC); // If the save result is a secret, whisper it to the GM. if(effectResults.hideSave) sendChat('Admiral Ackbar', '/w gm ' + saveMsg.toString(TrapTheme.css)); else content.append(saveMsg); } // Add the hit/miss message. if(effectResults.trapHit === 'AC unknown') { content.append('.paddedRow', 'AC could not be determined with the current version of your character sheet. For the time being, please resolve the attack against AC manually.'); if(effectResults.damage) content.append('.paddedRow', 'Damage: [[' + effectResults.damage + ']]'); } else if(effectResults.trapHit) { let row = content.append('.paddedRow') row.append('span.hit', 'HIT! '); if(effectResults.damage) row.append('span', 'Damage: [[' + effectResults.damage + ']]'); else row.append('span', 'You fall prey to the ' + effectResults.type + '\'s effects!'); } else { let row = content.append('.paddedRow'); row.append('span.miss', 'MISS! '); if(effectResults.damage && effectResults.missHalf) row.append('span', 'Half damage: [[floor((' + effectResults.damage + ')/2)]].'); } } return TrapTheme.htmlTable(content, '#a22', effectResults); } /** * @inheritdoc */ modifyTrapProperty(trapToken, argv) { let trapEffect = (new TrapEffect(trapToken)).json; let prop = argv[0]; let params = argv.slice(1); log(prop); log(params); if(prop === 'attack') trapEffect.attack = parseInt(params[0]); if(prop === 'damage') trapEffect.damage = params[0]; if(prop === 'hideSave') trapEffect.hideSave = params[0] === 'yes'; if(prop === 'missHalf') trapEffect.missHalf = params[0] === 'yes'; if(prop === 'save') trapEffect.save = params[0] === 'none' ? undefined : params[0]; if(prop === 'saveDC') trapEffect.saveDC = parseInt(params[0]); if(prop === 'spotDC') trapEffect.spotDC = parseInt(params[0]); trapToken.set('gmnotes', JSON.stringify(trapEffect)); } /** * @inheritdoc */ passiveSearch(trap, charToken) { let effect = (new TrapEffect(trap, charToken)).json; let character = getObj('character', charToken.get('represents')); // Only do passive search for traps that have a spotDC. if(effect.spotDC && character) { // If the character's passive perception beats the spot DC, then // display a message and mark the trap's trigger area. return this.getPassivePerception(character) .then(passWis => { if(passWis >= effect.spotDC) { let html = TrapTheme.htmlNoticeTrap(character, trap); ItsATrap.noticeTrap(trap, html.toString(TrapTheme.css)); } }) .catch(err => { sendChat('Trap theme: ' + this.name, '/w gm ' + err.message); log(err.stack); }); } } } })(); /** * Base class for TrapThemes using D&D 4E-ish rules. * @abstract */ var D20TrapTheme4E = (() => { 'use strict'; return class D20TrapTheme4E extends D20TrapTheme { /** * @inheritdoc */ activateEffect(effect) { let character = getObj('character', effect.victim.get('represents')); let effectResult = effect.json; Promise.resolve() .then(() => { effectResult.character = character; // Automate trap attack mechanics. if(character && effectResult.defense && effectResult.attack) { return Promise.all([ this.getDefense(character, effectResult.defense), CharSheetUtils.rollAsync('1d20 + ' + effectResult.attack) ]) .then(tuple => { let defenseValue = tuple[0]; let attackRoll = tuple[1]; defenseValue = defenseValue || 0; effectResult.defenseValue = defenseValue; effectResult.roll = attackRoll; effectResult.trapHit = attackRoll.total >= defenseValue; return effectResult; }); } return effectResult; }) .then(effectResult => { let html = D20TrapTheme4E.htmlTrapActivation(effectResult); effect.announce(html.toString(TrapTheme.css)); }) .catch(err => { sendChat('Trap theme: ' + this.name, '/w gm ' + err.message); log(err.stack); }); } /** * Gets the value for one of a character's defenses. * @param {Character} character * @param {string} defenseName * @return {Promise} */ getDefense(character, defenseName) { throw new Error('Not implemented.'); } /** * Creates the HTML for an activated trap's result. * @param {object} effectResult * @return {HtmlBuilder} */ static htmlTrapActivation(effectResult) { let content = new HtmlBuilder('div'); // Add the flavor message. content.append('.paddedRow trapMessage', effectResult.message); if(effectResult.character) { // Add the attack roll message. if(_.isNumber(effectResult.attack)) { let rollHtml = D20TrapTheme.htmlRollResult(effectResult.roll, '1d20 + ' + effectResult.attack); let row = content.append('.paddedRow'); row.append('span.bold', 'Attack roll: '); row.append('span', rollHtml + ' vs ' + effectResult.defense + ' ' + effectResult.defenseValue) } // Add the hit/miss message. if(effectResult.trapHit) { let row = content.append('.paddedRow'); row.append('span.hit', 'HIT! '); if(effectResult.damage) row.append('span', 'Damage: [[' + effectResult.damage + ']]'); else row.append('span', effectResult.character.get('name') + ' falls prey to the trap\'s effects!'); } else { let row = content.append('.paddedRow'); row.append('span.miss', 'MISS! '); if(effectResult.damage && effectResult.missHalf) row.append('span', 'Half damage: [[floor((' + effectResult.damage + ')/2)]].'); } } return TrapTheme.htmlTable(content, '#a22', effectResult); } }; })(); /** * The default system-agnostic Admiral Ackbar theme. * @implements TrapTheme */ (() => { 'use strict'; class DefaultTheme { /** * @inheritdoc */ get name() { return 'default'; } /** * @inheritdoc */ activateEffect(effect) { let content = new HtmlBuilder('.paddedRow', effect.message); let table = TrapTheme.htmlTable(content, '#a22', effect); let tableView = table.toString(TrapTheme.css); effect.announce(tableView); } /** * @inheritdoc */ passiveSearch(trap, charToken) { // Do nothing. } } ItsATrap.registerTheme(new DefaultTheme()); })();